typed-json 0.1.1

A JSON serialization file format
Documentation

Typed JSON   Latest Version

Typed JSON provides a json! macro to build an impl serde::Serialize type with very natural JSON syntax.

use typed_json::json;

// The type of `john` is `impl serde::Serialize`
let john = json!({
    "name": "John Doe",
    "age": 43,
    "phones": [
        "+44 1234567",
        "+44 2345678"
    ]
});

// Convert to a string of JSON and print it out
println!("{}", serde_json::to_string(&john).unwrap());

One neat thing about the json! macro is that variables and expressions can be interpolated directly into the JSON value as you are building it. Serde will check at compile time that the value you are interpolating is able to be represented as JSON.

let full_name = "John Doe";
let age_last_year = 42;

fn random_phone() -> String {
    "0".to_owned()
}

// The type of `john` is `impl serde::Serialize`
let john = typed_json::json!({
    "name": full_name,
    "age": age_last_year + 1,
    "phones": [
        format!("+44 {}", random_phone())
    ]
});

Comparison to serde_json

This crate provides a typed version of serde_json::json!(). What does that mean? It means it performs 0 allocations and it creates a custom type for the JSON object you are representing. For one-off JSON documents, this ends up being considerably faster to encode. This is 100% compatible with serde_json::json!() syntax as of serde_json = "1.0.108".

Benchmark

The following benchmarks indicate serializing a complex deeply-nested JSON document to a String.

Note: the typed_json_core benchmark uses serde-json-core to encode to a heapless::String.

Timer precision: 41 ns
serialize_string    fastest       │ slowest       │ median        │ mean          │ samples │ iters
├─ serde_json       765.3 ns      │ 15.1 µs       │ 807 ns        │ 824.9 ns      │ 100000  │ 800000
├─ typed_json       148.1 ns      │ 1.606 µs      │ 153.3 ns      │ 156 ns        │ 100000  │ 3200000
╰─ typed_json_core  217.1 ns      │ 2.991 µs      │ 228.8 ns      │ 240.4 ns      │ 100000  │ 3200000

Note: The benchmarks use serde_json::to_string as it's significantly faster than the ToString/Display implementation, both for serde_json::json and typed_json::json

No-std support

It is possible to use typed_json with only core. Disable the default "std" feature:

[dependencies]
typed_json = { version = "0.1", default-features = false }

To encode the Serialize type to JSON:

you will either need serde_json with the alloc feature

[dependencies]
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }

or serde-json-core with no dependency on alloc

[dependencies]
serde-json-core = "0.5.1"

How it works

Note: all of this is implementation detail and none of this is stable API

let data = json!({
    "codes": [400, value1, value2],
    "message": value3,
    "contact": "contact support at support@example.com"
});

Expands into something like

let data = typed_json::__private::Map(hlist![
    typed_json::__private::KV::Pair(
        typed_json::__private::Expr("codes"),
        typed_json::__private::Array(hlist![
            typed_json::__private::Expr(400),
            typed_json::__private::Expr(value1),
            typed_json::__private::Expr(value2),
        ]),
    ),
    typed_json::__private::KV::Pair(
        typed_json::__private::Expr("message"),
        typed_json::__private::Expr(value3)
    ),
    typed_json::__private::KV::Pair(
        typed_json::__private::Expr("contact"),
        typed_json::__private::Expr("contact support at support@example.com")
    ),
]);

where hlist![a, b, c, d, e] would expand into

(a, ((b, c), (d, e)))

Compile time benchmarks

There's no such thing as a true zero-cost abstraction. However, it seems that sometimes typed-json compiles faster than serde_json, and sometimes the opposite is true.

I measured the compile times using the large service JSON from https://kubernetesjsonschema.dev/.

Many small documents

In this test, I have split the above JSON file into 31 reasonably-sized documents

Debug

$ hyperfine \
    --command-name "typed_json" \
    "pushd tests/crates/stress3 && touch src/main.rs && cargo build" \
    --command-name "serde_json" \
    "pushd tests/crates/stress4 && touch src/main.rs && cargo build"

Benchmark 1: typed_json
  Time (mean ± σ):     148.6 ms ±   3.7 ms    [User: 141.2 ms, System: 82.0 ms]
  Range (min … max):   143.3 ms … 157.0 ms    20 runs
 
Benchmark 2: serde_json
  Time (mean ± σ):     151.7 ms ±   4.8 ms    [User: 134.9 ms, System: 98.5 ms]
  Range (min … max):   143.2 ms … 163.0 ms    20 runs
 
Summary
  typed_json ran
    1.02 ± 0.04 times faster than serde_json

Release

$ hyperfine \
    --command-name "typed_json" \
    "pushd tests/crates/stress3 && touch src/main.rs && cargo build --release" \
    --command-name "serde_json" \
    "pushd tests/crates/stress4 && touch src/main.rs && cargo build --release"

Benchmark 1: typed_json
  Time (mean ± σ):     538.3 ms ±   7.1 ms    [User: 877.5 ms, System: 65.7 ms]
  Range (min … max):   527.4 ms … 550.9 ms    10 runs
 
Benchmark 2: serde_json
  Time (mean ± σ):      1.003 s ±  0.013 s    [User: 1.194 s, System: 0.075 s]
  Range (min … max):    0.972 s …  1.020 s    10 runs
 
Summary
  typed_json ran
    1.86 ± 0.04 times faster than serde_json

One-off large document

In this test, I have included the single JSON file in verbatim. I don't think this is a realistic use case but still interesting

Debug

$ hyperfine \
    --command-name "typed_json" \
    "pushd tests/crates/stress1 && touch src/main.rs && cargo build" \
    --command-name "serde_json" \
    "pushd tests/crates/stress2 && touch src/main.rs && cargo build"

Benchmark 1: typed_json
  Time (mean ± σ):     157.5 ms ±   6.1 ms    [User: 147.9 ms, System: 83.5 ms]
  Range (min … max):   152.1 ms … 178.4 ms    18 runs
 
Benchmark 2: serde_json
  Time (mean ± σ):     151.7 ms ±   4.5 ms    [User: 133.6 ms, System: 97.9 ms]
  Range (min … max):   145.1 ms … 162.4 ms    18 runs
 
Summary
  serde_json ran
    1.04 ± 0.05 times faster than typed_json

Release

$ hyperfine \
    --command-name "typed_json" \
    "pushd tests/crates/stress1 && touch src/main.rs && cargo build --release" \
    --command-name "serde_json" \
    "pushd tests/crates/stress2 && touch src/main.rs && cargo build --release"

Benchmark 1: typed_json
  Time (mean ± σ):      1.501 s ±  0.012 s    [User: 2.324 s, System: 0.090 s]
  Range (min … max):    1.480 s …  1.520 s    10 runs
 
Benchmark 2: serde_json
  Time (mean ± σ):     947.3 ms ±  20.4 ms    [User: 1142.0 ms, System: 71.2 ms]
  Range (min … max):   918.7 ms … 989.0 ms    10 runs
 
Summary
  serde_json ran
    1.58 ± 0.04 times faster than typed_json

Conclusion

I don't think I can conclusively say that typed-json introduces a compile-time regression in standard use. At the extremes, it likely will need to compile many more types but in standard use, it can re-use a lot of prior compilations.